昨天的結論是:一個 decision 只有一個地方知道,它改變的時候,就只有那裡需要改。
這件事有一個更有名的名字:DRY,Don't Repeat Yourself。
多數人學到的 DRY 是這樣的:看到重複的 code,就抽成一個 function。我自己一開始也是這樣理解的。這個做法大部分時候沒有問題,但它會在兩個地方出錯:有些長得一樣的 code 不該合併,有些重複的東西,長得一點都不像。
DRY 出自 The Pragmatic Programmer,原文是:
Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.
系統裡的每一項 knowledge,都應該只有一個明確、權威的表示。
主詞是 knowledge,不是 code。
Knowledge 是系統為了正確運作,必須知道的規則、事實或約定。它可以大到一整套商業決策 (Decision),也可以小到一份 contract、一個欄位的意思。
而今天 knowledge 如果相同,卻在不同的地方各自定義與維護,這對於人與 agent 來說,都是額外的負擔。
所以 DRY 問的不是「這兩段 code 像不像」,而是:
這兩個地方,是不是各自維護著同一份 knowledge?
「長得像不像」與「是不是同一份 knowledge」是兩個獨立的問題,組合起來有四種情況:
| 同一份 knowledge | 不同的 knowledge | |
|---|---|---|
| 長得一樣 | ① 真的重複 | ② 碰巧一樣 |
| 長得不一樣 | ③ 看不見的重複 | ④ 沒有關係 |
④:沒啥問題先 pass
①:最容易發現,並且能透過「看到重複就抽 function」的方式簡單處理:e.g., 同一條折扣規則在 cart、order、invoice 各寫了一份,就該收進 pricing。
容易有問題的反而是
②:合併不該合併的東西
③:沒意識到其實邏輯重複了
pricing 裡有兩條規則:滿 1000 折 100,滿 1000 免運。
# shop/pricing/rules.py
def discount_of(subtotal: Price) -> Price:
return 100 if subtotal >= 1000 else 0
def shipping_fee_of(subtotal: Price) -> Price:
return 0 if subtotal >= 1000 else 60
subtotal >= 1000 出現了兩次。照著直覺,把它抽出來:
def is_big_order(subtotal: Price) -> bool:
return subtotal >= 1000
重複消失了。過了一個月,物流因為運費調漲,把免運門檻提高到 1500。折扣沒有要變,所以 is_big_order 不能直接改,只好加一個參數:
def is_big_order(subtotal: Price, *, for_shipping: bool = False) -> bool:
if for_shipping:
return subtotal >= 1500
return subtotal >= 1000
再過一個月,行銷想讓會員滿 800 就能折抵,於是又多一個 is_member。
回頭看發生了什麼事。折扣門檻由行銷決定,免運門檻由物流決定;它們從一開始就是兩件事,只是在某一天剛好都是 1000。合併之後,兩段原本互不相干的 code 被綁在一起:改其中一邊,就得顧慮另一邊,每個 client 還得透過參數說明「我是哪一種」。
重複的 code 是線索,不是證據。合併兩段 code,等於宣告它們以後會一起改。
其實有一個訊號很早就出現了:名字。is_big_order 是什麼?沒有任何人做過「多少錢算大訂單」這個決定。真正的 knowledge 都叫得出名字:「折扣門檻」、「免運門檻」。如果抽出來的東西只能用它的長相來命名,它背後很可能並沒有一份 knowledge。
反過來的情況更難發現。行銷用一張試算表維護促銷活動,系統把每一列讀成一個 dict,key 是試算表的欄名:
row = {"A": 1000, "B": 100} # 滿 1000 折 100
# shop/pricing/discount.py
def discount_of(subtotal: Price, row: dict[str, Any]) -> Price:
return row["B"] if subtotal >= row["A"] else 0
# shop/cart/banner.py
def promo_banner(row: dict[str, Any]) -> str:
return f"滿 {row['A']} 折 {row['B']}"
這兩段 code 沒有任何一行相同,找重複的工具不會把它們列出來,兩者之間也不存在依賴關係。但它們知道同一件事:A 欄是門檻,B 欄是折抵金額。重複的不是 row["A"] 這幾個字,而是這個知識點;它沒有寫在任何地方,兩邊各自記著。
哪天行銷在最前面插一欄活動名稱,row 變成 {"A": "週年慶", "B": 1000, "C": 100}。discount_of 跟著改了,promo_banner 卻被漏掉。首頁從此顯示「滿 週年慶 折 1000」,也沒有任何錯誤訊息。
這種重複有很多種樣子:
dict[str, Any],每一個都得先弄懂它長什麼樣子。if status == "paid" 散在各處。total,但它又能由商品與折扣算出來。它們的共同點是:兩個地方必須對同一件事有共識,卻沒有任何地方寫著它們必須一致。換句話說,這是一份沒寫出來的 knowledge。
解法是替這份 knowledge 找一個家。以上面的例子來說,可以定義一個型別 Promo,把欄位的知識交給它:由 Promo.from_row() 把 row 轉成 promo.threshold 與 promo.amount;pricing 與 cart 都透過它讀,不必自己再知道一次欄位。
若是收不進同一個 module 的,例如前端與後端(由於不同語言),就共用一份 schema,或由其中一邊產生另一邊。
這與 abstraction 有點類似,都是建立一個更容易理解的 boundary,將 knowledge 用統一的方式呈現。這也是 DRY 最主要的手段。但反過來不成立:DRY 的前提是同一份 knowledge 現在已經有兩份;為了還沒出現的做法先準備 interface,是另一個問題。
判斷只需要問一件事:
它們會因為同一個理由一起改嗎?
先留著,是因為兩種錯誤的代價並不對稱。
留著一份真的重複,之後發現了再合併並不難:兩段 code 都還在,各自的需求也還看得清楚。合併了兩個碰巧一樣的東西,代價卻會隨時間增加:每個新需求都往共用的 function 裡多加一個參數、一個 if,等到想拆開,已經分不清哪個 client 依賴的是哪一種行為。
常聽到的 Rule of Three(第三次重複才抽)也是這個意思。「三」沒有什麼魔力,它只是在等證據。
重複比錯誤的 abstraction 便宜。還看不出來的時候,先讓它重複。
回到開頭。DRY 不是「不要有長得一樣的 code」,而是:
同一份 knowledge,只留一份;不同的 knowledge,長得再像,也不要合成一份。
對 coding agent 而言,② 與 ③ 都特別容易發生:
discount_of 讀的欄位,不會知道 promo_banner 在另一頭還照著舊的欄位讀。這兩種結果,一樣能通過所有的檢查。所以與 agent 合作時,可以多做兩件事:
Promo 可以知道」是一種;「折扣門檻與免運門檻數字相同是巧合,不要合併」是另一種。後者值得直接寫在 code 旁邊,因為只看 code,看不出這份重複是刻意留下的。兩段 code 該不該合併,從長相看不出來。要看的是:它們知道的,是不是同一件事。